1. 挂载子节点与元素属性
1.1 子节点的挂载
虚拟 DOM 的核心是虚拟节点(vnode),它描述了 DOM 树的结构。一个元素可能包含文本子节点或其他元素子节点,vnode 的 children
属性可以是字符串(表示文本内容)或数组(表示多个子节点)。例如:
const vnode = {
type: 'div',
children: [
{ type: 'p', children: 'hello' }
]
}
为了挂载子节点,我们需要在 mountElement
函数中处理 children
的不同类型:
function mountElement(vnode, container) {
const el = document.createElement(vnode.type)
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el)
})
}
container.appendChild(el)
}
这里,patch
函数递归调用 mountElement
来挂载子节点,null
表示没有旧节点(初次挂载),el
作为挂载点确保子节点挂载到正确位置。
1.2 元素属性的处理
元素属性通过 vnode.props
定义,例如:
const vnode = {
type: 'div',
props: { id: 'foo' },
children: 'hello'
}
在 mountElement
中,我们遍历 props
并设置属性:
function mountElement(vnode, container) {
const el = document.createElement(vnode.type)
// 处理子节点(省略)
if (vnode.props) {
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key])
}
}
container.appendChild(el)
}
但直接使用 setAttribute
并不总是最佳选择,因为 HTML Attributes 和 DOM Properties 之间存在差异。
2. HTML Attributes 与 DOM Properties
2.1 两者的区别
HTML Attributes 是写在 HTML 标签上的属性,如 <input id="my-input" value="foo">
中的 id
和 value
。浏览器解析后会生成 DOM 对象,DOM Properties 是这些对象的属性,如 el.id
和 el.value
。
两者的关系复杂,主要体现在:
- 映射关系:某些 Attributes 直接映射到 Properties(如
id
),但也有例外(如 class
对应 el.className
)。
- 初始值与当前值:Attributes 设置 Properties 的初始值,Properties 反映当前值。例如,用户修改
<input>
的值后,el.value
变为新值,但 el.getAttribute('value')
仍返回初始值。
- 只读属性:如
<input form="form1">
的 form
属性,el.form
是只读的,只能通过 setAttribute
设置。
核心原则:HTML Attributes 用于设置 DOM Properties 的初始值。
2.2 正确设置属性
为了正确设置属性,我们需要区分属性是否对应 DOM Properties,并处理特殊情况(如布尔属性)。以下是改进的实现:
function shouldSetAsProps(el, key, value) {
if (key === 'form' && el.tagName === 'INPUT') return false
return key in el
}
function mountElement(vnode, container) {
const el = document.createElement(vnode.type)
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key]
if (shouldSetAsProps(el, key, value)) {
const type = typeof el[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
el.setAttribute(key, value)
}
}
}
// 处理子节点(省略)
container.appendChild(el)
}
这里,shouldSetAsProps
判断属性是否应作为 DOM Properties 设置,并对布尔属性(如 disabled
)做特殊处理,确保空字符串被矫正为 true
。
3. 特殊属性的处理:以 class 为例
Vue.js 增强了 class
属性的处理,支持字符串、对象和数组形式:
const vnode = {
type: 'p',
props: {
class: ['foo bar', { baz: true }]
}
}
需要将不同类型的 class
值归一化为字符串:
function normalizeClass(value) {
let res = ''
if (typeof value === 'string') {
res = value
} else if (Array.isArray(value)) {
value.forEach(item => {
res += normalizeClass(item) + ' '
})
} else if (typeof value === 'object') {
for (const key in value) {
if (value[key]) res += key + ' '
}
}
return res.trim()
}
在 patchProps
中优先使用 el.className
设置 class
,因为其性能优于 setAttribute
和 el.classList
:
function patchProps(el, key, prevValue, nextValue) {
if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key]
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
el.setAttribute(key, nextValue)
}
}
style
属性也需要类似处理,涉及值归一化和性能优化。
4. 卸载操作
卸载操作在更新阶段触发,例如渲染 null
:
renderer.render(null, container)
直接使用 container.innerHTML = ''
不够严谨,因为:
- 可能遗漏组件的生命周期钩子(如
beforeUnmount
)。
- 无法触发自定义指令的钩子。
- 不会移除绑定的事件处理函数。
正确的做法是记录 vnode.el
与真实 DOM 的关联,并在卸载时移除具体元素:
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) parent.removeChild(vnode.el)
}
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else if (container._vnode) {
unmount(container._vnode)
}
container._vnode = vnode
}
5. 区分 vnode 类型
vnode 的 type
属性决定其类型:
- 字符串:普通标签(如
'div'
)。
- 对象:组件。
- Symbol:特殊节点(如文本、注释、Fragment)。
在 patch
函数中根据类型调用不同逻辑:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
// 处理文本节点(后文详述)
} else if (type === Fragment) {
// 处理 Fragment(后文详述)
}
}
6. 事件处理
6.1 事件描述与绑定
事件通过 vnode.props
中以 on
开头的属性描述:
const vnode = {
type: 'p',
props: { onClick: () => alert('clicked') }
}
在 patchProps
中绑定事件:
function patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const name = key.slice(2).toLowerCase()
el.addEventListener(name, nextValue)
} else {
// 属性处理(省略)
}
}
6.2 事件更新优化
为避免重复调用 removeEventListener
,使用伪造事件处理函数 invoker
:
function patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
const name = key.slice(2).toLowerCase()
let invoker = invokers[key]
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else {
// 属性处理(省略)
}
}
支持同一事件绑定多个处理函数(如 onClick: [fn1, fn2]
)。
6.3 事件冒泡与更新时机
事件冒泡可能导致意外触发。例如,子节点事件触发响应式数据更新,导致父节点绑定新事件,而冒泡又触发了新事件。解决方案是比较事件触发时间与绑定时间:
function patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
const name = key.slice(2).toLowerCase()
let invoker = invokers[key]
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else {
// 属性处理(省略)
}
}
7. 子节点更新
子节点更新涉及 vnode.children
的三种情况:null
、字符串、数组。新旧子节点组合有九种可能,但代码中只需处理关键场景:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
}
container.textContent = n2.children
} else if (Array.isArray(n2.children)) {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
n2.children.forEach(c => patch(null, c, container))
} else {
container.textContent = ''
n2.children.forEach(c => patch(null, c, container))
}
} else {
if (Array.isArray(n1.children)) {
n1.children.forEach(c => unmount(c))
} else if (typeof n1.children === 'string') {
container.textContent = ''
}
}
}
后续章节会介绍 Diff 算法优化数组子节点的更新。
8. 文本节点与注释节点
文本节点和注释节点使用 Symbol 标识:
const Text = Symbol()
const Comment = Symbol()
const textVNode = { type: Text, children: '文本内容' }
const commentVNode = { type: Comment, children: '注释内容' }
在 patch
函数中处理:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (type === Text) {
if (!n1) {
const el = n2.el = document.createTextNode(n2.children)
container.appendChild(el)
} else {
const el = n2.el = n1.el
if (n2.children !== n1.children) {
el.nodeValue = n2.children
}
}
}
// 其他类型处理(省略)
}
为跨平台,封装 createText
和 setText
:
const renderer = createRenderer({
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
}
})
9. Fragment
Fragment 允许组件返回多个根节点:
const Fragment = Symbol()
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' }
]
}
在 patch
中只渲染子节点:
function patch(n1, n2, container) {
if (type === Fragment) {
if (!n1) {
n2.children.forEach(c => patch(null, c, container))
} else {
patchChildren(n1, n2, container)
}
}
// 其他类型处理(省略)
}
卸载 Fragment 时逐个卸载子节点:
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) parent.removeChild(vnode.el)
}